"use strict";

const inquirer = require('inquirer');
const bent = require('bent');
const fs = require('fs');
const util = require('util');
const stream = require('stream');
const pipeline = util.promisify(stream.pipeline);
const path = require('path');
const async = require('async');
const chalk = require('chalk');
const ora = require('ora');

const spinner = ora({spinner: { interval: 100, frames: ['.', 'o', 'O', '@', '*'] }}); // global spinner
const MAX_RETRY = 10;
const RETRY_INTERVAL = 2000;
const NETWORK_ERRORS = ['ENOTFOUND', 'ECONNRESET', 'ETIMEDOUT', 'ISC503', 'MAXQUERIES'] // ISC503 = Incorrect status code 503

/*******************************************
 ************* Requests  *******************
 *******************************************/

const retryRequest = async (retryCount, func, params=[]) => {
  if (retryCount==MAX_RETRY && spinner.text.indexOf('Error')==-1)
    spinner.warn('Aaaah! Network probleeems!');
  return await new Promise(resolve => {
    setTimeout(() => resolve(func(...params, retryCount-1)), RETRY_INTERVAL);
  });
}

const getSpotifyToken = async (retry=MAX_RETRY) => {
  // The user-agent header is required. Otherwise, the request returns a 400 status code
  const getJson = bent('GET', 'json', { 'User-Agent': 'Mozilla/5.0', }, 200);

  let json = null;
  try{
    json = await getJson('https://open.spotify.com/get_access_token');
  }
  catch(err){
    if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getSpotifyToken);
    else throw err;
  }
  return json['accessToken'];
}

const getPlaylistFromURL = async (url, retry=MAX_RETRY) => {
  // Retrieve information necessary to make the request
  let token = await getSpotifyToken();
  let [_, type, id]  = url.match(/((?:album)|(?:playlist))\/((?:\d|[a-z]|[A-Z])+)(?:\?si=)?/);
  let isAlbum = type==="album";

  // Make request to spotify API
  const requestSpotify = bent('GET', 'json', 200, { 'Authorization': `Bearer ${token}` });

  let response = null;
  try{
    response = await requestSpotify(`https://api.spotify.com/v1/${type}s/${id}`); // to /albums or /playlists
  }
  catch(err){
    if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getPlaylistFromURL, [plUrl]);
    else throw err;
  }

  let playlist = {};

  // Parse response
  playlist['name'] = response['name'];
  playlist['totalSongs'] = response['tracks']['total'];
  playlist['owner'] = (isAlbum?
    response['artists'][0]['name'] :
    response['owner']['display_name']
  );

  response = response['tracks'];
  playlist['songs'] = response['items'].map(song => ({
    'artist': (isAlbum?
      song['artists'][0]['name'] :
      song['track']['artists'][0]['name']
    ),
    'title': (isAlbum?
      song['name'] :
      song['track']['name']
    )
  }));

  // The Spotify API has a limit for 100 tracks per request
  // if the playlist has more than 100 tracks, the response
  // contains a 'next' url for the next 100 tracks.
  // Albums have a 50 limit instead of 100.
  let counter = 0;
  let nullSongs = [];

  while (response['next']){
    counter += isAlbum? 50 : 100;
    spinner.text = `Loading playlist details. ${counter} songs processed`;

    let nextPage = response['next'];
    try {
      response = await requestSpotify(nextPage)
    }
    catch(err){

      if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getPlaylistFromURL, [plUrl]);
      else throw err;
    }

    // Parse response
    playlist['songs'] = playlist['songs'].concat(response['items'].map(song => {
      if (!isAlbum) {
        if (!song['track']) return null; // empty song in playlist
        song = song['track']; // playlist tracks contain artist/title in song['track']
                              // album tracks don't have a 'track' key
      }

      return  {
        'artist': song['artists'][0]['name'],
        'title': song['name']
      }
    }));
  }

  playlist["songs"] = playlist["songs"].filter(song => {
    return song!=null;
  });

  playlist["totalSongs"] = playlist["songs"].length;

  return playlist;
}

const getDeezerSongUrl = async (song, retry=MAX_RETRY) => {
  /*
    @return {String} URL of song in Deezer
            {null}   If song couldn't be found
  */
  const deezerSearch = bent('https://api.deezer.com/search?q=', 'GET', 'json', 200);
  let bestTitle = song.title.replace(/(feat\.? )|(ft\.? )|(with )|(con )/, '');
  let encodedSong = encodeURIComponent(`${song.artist} ${bestTitle}`);

  let results = null;
  try {
    results = await deezerSearch(encodedSong);
    if ('error' in results) throw {...results['error'], code:'DZERROR'};
  }
  catch(err){
    if (err.statusCode) err['code'] = `ISC${err.statusCode}`;
    if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getDeezerSongUrl, [song]);
    else throw err;
  }

  // Parse response
  results = results['data'];
  if (!results.length)
    return null;
  return results[0]['link'];
}

const downloadDeezerSong = async (song, directory, quality, retry=MAX_RETRY) => {
  /*
    @return {String} Path to downloaded song
  */

  // Get redirected URL
  // The dzloader API receives the deezer URL and redirects to the file URL
  const requestDzloader = bent('https://dz.loaderapp.info', 'GET', 200, 302);

  let redirectedRes = null;
  try{
    redirectedRes = await requestDzloader(`/deezer/${quality}/${song.deezerUrl}`);
  }
  catch(err){
    if (err.statusCode) err['code'] = `ISC${err.statusCode}`;
    if (NETWORK_ERRORS.includes(err.code) && retry)
      return await retryRequest(retry, downloadDeezerSong, [song, directory, quality]);
    else throw err;
  }

  let downloadEndpoint = redirectedRes.headers['location'];

  // Download file
  let bytes = null;
  try{
    bytes = await requestDzloader(downloadEndpoint);
  }
  catch(err){
    if (err.statusCode) err['code'] = `ISC${err.statusCode}`;
    if (NETWORK_ERRORS.includes(err.code) && retry)
      return await retryRequest(retry, downloadDeezerSong, [song, directory, quality]);
    else throw err;
  }

  // Create file
  let extension = quality==1411? '.flac' : '.mp3';
  let filepath = path.join(directory, song.displayName.replace(/[/\\?%*:|"<>]/, '') + extension);
  const file = fs.createWriteStream(filepath);

  await pipeline(bytes, file).catch(err => { throw err; });

  return file.path;
}

const downloadSpotifySong = async (song, directory, quality) => {
  /*
    @return {String} Path to downloaded song
  */
  let deezerUrl = await getDeezerSongUrl(song).catch(async err => { throw err; });
  if (!deezerUrl) throw {'code': 'NFDEEZER'};
  song['deezerUrl'] = deezerUrl;
  return await downloadDeezerSong(song, directory, quality).catch(async err => {throw err;});
}


/*******************************************
 ************* Interface *******************
 *******************************************/

const displaySptDl = () => {
  process.stdout.write(chalk.green(' ██████  ██       ███████ ██████  ████████\n'));
  process.stdout.write(chalk.green(' ██   ██ ██       ██      ██   ██    ██   \n'));
  process.stdout.write(chalk.green(' ██   ██ ██ █████ ███████ ██████     ██   \n'));
  process.stdout.write(chalk.green(' ██   ██ ██            ██ ██         ██   \n'));
  process.stdout.write(chalk.green(' ██████  ███████  ███████ ██         ██ v1.3\n\n'));
                                        
}

const formatTitle = (title) => {
  return chalk.bold(title.toUpperCase());
}

const fillStringTemplate = (template, values) => template.replace(/%(.*?)%/g, (x,g)=> values[g]);

const songToString = (song, firstArtist=true) => {
  if (firstArtist) return (song.artist + ' - ' + song.title);
  return (song.title + ' - ' + song.artist);
}

const displayQuestions = async () => {
  let questions = [
    {
      name: 'playlistUrl',
      prefix: '♪',
      message: 'Spotify playlist or album url: ',
      validate: (input) => {
        let regExVal = /^(https:\/\/)?open\.spotify\.com\/((playlist)|(album))\/(\d|[a-z]|[A-Z])+(\?si=.*)?$/;
        if (!regExVal.test(input))
          return 'You sure this is a Spotify URL?';
        return true;
      }
    },
    {
      type: 'list', 
      name: 'quality',
      prefix: '♪',
      message: 'Select mp3 quality',
      choices: [
        {'name': '128 kpps (mp3)', 'value': 128},
        {'name': '320 kpps (mp3)', 'value' : 320},
        {'name': '1411 kpps (flac)', 'value' : 1411}
      ]
    },
    {
      name: 'directory',
      prefix: '♪',
      message: 'Where do you want to download your songs?',
      validate: input => {
        if (!fs.existsSync(input))
          return 'Eeeh... this path doesn\'t exist';
        return true;
      }
    },
    {
      type: 'list',
      prefix: '♪',
      name: 'displayNameTemplate',
      message: `How do you prefer to name your songs?
  For example: The Beatles - Hey Jude (%artist% - %title%)
               Hey Jude - The Beatles (%title% - %artist%)\n`,
      choices: [
        '%artist% - %title%',
        '%title% - %artist%'
      ]
    },
    {
      prefix: '⚡',
      name: 'parallelDownloads',
      message: 'Number of parallel downloads (Leave empty if you don\'t know what\'s this): ',
      validate: input => {
        return /^[1-9]|10$/.test(input)? true : 'Noo! Just enter a value between 1 and 10';
      },
      default: 6
    }
  ];

  let answers = await inquirer.prompt(questions);

  // Add 'https:// to spotify url if missing'
  if (!/^https:\/\//.test(answers.playlistUrl))
    answers.playlistUrl = 'https://'+answers.playlistUrl;

  return answers;
}

const displayPlaylistInfo = async playlist => {
  /* @param playlist {Object}
  *         .name           {String}  Playlist/Album name
  *         .owner          {String}  Owner name
  *         .totalSongs     {Number}  Number of songs in playlist
  *         .songs          {Object}
  *           .artist       {String}  Name of song artist
  *           .title        {String}  Song title
  *           .displayName  {String}  Song name to display
  */

  process.stdout.write(formatTitle('Album/Playlist Details\n'));
  process.stdout.write(chalk.bold('Name: ') + playlist.name + '\n');
  process.stdout.write(chalk.bold('Owner: ') + playlist.owner + '\n');
  process.stdout.write(chalk.bold('Total valid songs: ') + playlist.totalSongs + '\n');

  let answers = await inquirer.prompt([
    {
      type: 'checkbox',
      prefix: '♪',
      name: 'songsToDl',
      message: 'Uncheck any song you DON\'T want to download',
      pageSize: process.stdout.rows - 7, // 5 = 3 process.stdout.write before + inquire logs + 2 extra space
      choices: playlist.songs.map(song => (
        {
          'name': song.displayName,
          'value': song,
          'checked': true
        })
      )
    }
  ]);

  return answers['songsToDl'];
}

const displayFinalReport = (failedDownloads, totalSongs, directory) => {
  clearConsole();
  console.log('All done!\n');
  let nfailed = failedDownloads.length;
  let nsuccessful = totalSongs-failedDownloads.length;
  process.stdout.write(formatTitle('Final report\n'));
  process.stdout.write(`${nsuccessful} successful downloads.\nCouldn't download ${nfailed} songs\n`);

  if (!failedDownloads.length) return;

  let buff = '';
  for (let fail of failedDownloads){
    let line = `[${fail.error}] ${fail.song}\n`;
    buff += line;
    process.stdout.write(line);
  }

  // Display error summary
  let errors = failedDownloads.map(fail => fail.error);
  errors = errors.reduce((acum,cur) => Object.assign(acum,{[cur]: (acum[cur] | 0)+1}),{});

  buff += '\n';
  process.stdout.write(chalk.bold('\nError summary\n'));
  if ('NFDEEZER' in errors)
    process.stdout.write(`[NFDEEZER] Songs not found in Deezer: ${errors['NFDEEZER']}\n`);
  for (let error in errors){
    let line = '';
    if (error!=='NFDEEZER')
      line = `[${error}]: ${errors[error]}\n`;
    else
      buff += `[NFDEEZER]: ${errors['NFDEEZER']}\n`;
    buff+=line;
    process.stdout.write(line);
  }

  fs.writeFileSync(path.join(directory, 'failed-songs.txt'), buff);
  process.stdout.write(chalk.bold(`This report has been save in failed-songs.txt in ${directory}\n`))
}

const clearConsole = () => { console.clear(); }

/*******************************************
 *********** Control App Flow **************
 *******************************************/

(async () => {
  clearConsole();
  displaySptDl();

  /* Get input from user */
  let {playlistUrl, quality, directory, displayNameTemplate, parallelDownloads} = await displayQuestions();

  clearConsole();

  /* Get information about the Spotify playlist with the Spotify API.
     This might take a few seconds */

  spinner.start('Loading playlist details');
  let playlist = await getPlaylistFromURL(playlistUrl); 
  spinner.stop();

  /* Add display name attribute to songs */
  playlist.songs.forEach(song => {
    song['displayName'] = fillStringTemplate(displayNameTemplate, song);
  });

  /* Show playlist information and select sorted songs to download */
  playlist.songs.sort( (a,b) => {
    return a.displayName.toLowerCase() < b.displayName.toLowerCase()? -1 : a.displayName > b.displayName? 1 : 0;
  })
  let songsToDownload = await displayPlaylistInfo(playlist);

  clearConsole();

  /* Download songs */
  process.stdout.write(formatTitle('Downloading songs\n'));
  directory = path.join(directory, 'downloaded-songs');
  if (!fs.existsSync(directory)){
      fs.mkdirSync(directory);
  }
  
  // Download songs
  let finished = 0;
  let failedDownloads = [];

  await async.eachOfLimit(songsToDownload, parallelDownloads, async (song, songIndex) => {
    let errorCode = null;
    let filename = await downloadSpotifySong(song, directory, quality)
                          .catch(async err => {
                            errorCode =  err.code? err.code : err.message;
                          });

    finished+=1;

    if (!errorCode)
      process.stdout.write(`${chalk.green('√')} Finished ${song.displayName}`);
    else{
      process.stdout.write(chalk.red(`× Error [${errorCode}] Couldn\'t download ${song.displayName}`));
      failedDownloads.push({ 'song': song.displayName, 'error': errorCode });
    }
    process.stdout.write('');
    process.stdout.write(chalk.blue(` [${finished}/${playlist.totalSongs}` +
                         ` | ${parseInt(finished/playlist.totalSongs*100)}%` +
                         ` | ${failedDownloads.length} errors]\n`));
  })
  .catch(err => { if (err) throw err; });
  displayFinalReport(failedDownloads, playlist.totalSongs, directory);

  process.stdout.write('You can close this now. Press any key to exit.\n');
  process.stdin.setRawMode(true);
  process.stdin.resume();
  process.stdin.on('data', process.exit.bind(process, 0));
})();




